Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 | 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 5x 5x 5x 1x 1x 9x 9x 9x 9x 9x 1x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 7x 7x 7x 1x 1x 7x 9x 9x 9x 9x 5x 5x 4x 4x 4x 4x 4x 4x 9x 9x 9x 9x 9x 9x 9x 9x 3x 3x 4x 4x 4x | /**
* Doorbell webhook receiver (Phase 2a, #8.4).
*
* POST /api/abacus/print/ring/[connectionId] — called by the THH print
* service, NOT by a browser, so there is no session: the credential is the
* per-connection ring secret we registered via PUT /webhook, presented as a
* bearer token.
*
* Security posture — constant-time, fail-closed:
* • The secret comparison hashes both sides (sha256) then `timingSafeEqual`s
* the digests, so neither content nor length leaks through timing.
* • When the row or its secret is missing, we still burn a comparison
* against a process-local dummy and return the same bare 401. No branch
* of failure explains itself.
* • The 401 body carries no detail — a prober learns nothing about which
* connection ids exist.
*
* A valid ring is acked 204 and (throttled ~250ms per connection) fanned out
* to the owning user's socket room. The body is parsed tolerantly — a ring
* with no/garbage body is still a valid "something changed" hint.
*/
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { emitPrintJobUpdated } from '@/lib/abacus/print/ring-emitter'
import type { PrintJobUpdatedEvent } from '@/lib/abacus/print/ring-events'
import { claimRingSlot } from '@/lib/abacus/print/ring-throttle'
import { open } from '@/lib/secret-box'
/** Burned when there's no real secret to compare — equalizes the failure paths. */
const DUMMY_SECRET = randomBytes(32).toString('base64url')
function unauthorized(): NextResponse {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
}
/** Constant-time equality via digest comparison (length-safe for timingSafeEqual). */
function secretsMatch(presented: string, expected: string): boolean {
const a = createHash('sha256').update(presented).digest()
const b = createHash('sha256').update(expected).digest()
return timingSafeEqual(a, b)
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ connectionId: string }> }
) {
const { connectionId } = await params
const authHeader = request.headers.get('authorization') ?? ''
const presented = authHeader.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : ''
const row = await db.query.printServiceConnections.findFirst({
where: eq(schema.printServiceConnections.id, connectionId),
})
// Unseal defensively: a corrupt sealed value degrades to "no secret", not a 500.
let expected: string | null = null
if (row?.ringSecretSealed) {
try {
expected = open(row.ringSecretSealed)
} catch {
expected = null
}
}
// Always run the comparison, even when it can't succeed.
const matched = secretsMatch(presented, expected ?? DUMMY_SECRET)
if (!row || expected === null || presented === '' || !matched) {
return unauthorized()
}
// Tolerant body parse: identifiers only, best-effort. Anything malformed
// degrades to a bare "connection changed" hint.
const body = (await request.json().catch(() => null)) as Record<string, unknown> | null
const payload: PrintJobUpdatedEvent = {
connectionId,
...(typeof body?.jobId === 'string' ? { jobId: body.jobId } : {}),
...(typeof body?.printerId === 'string' ? { printerId: body.printerId } : {}),
...(typeof body?.phase === 'string' ? { phase: body.phase } : {}),
}
// Bursts collapse to one emit per interval; dropped rings are still acked —
// the re-read on the surviving emit (and the safety-net poll) covers them.
if (claimRingSlot(connectionId)) {
await emitPrintJobUpdated(row.userId, payload)
}
return new NextResponse(null, { status: 204 })
}
|